iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
生成式 AI

30 天一人公司的 AI 開發實戰系列 第 9

Day 09: 系統設計師從零到一:Kotlin Multiplatform 專案架構

  • 分享至 

  • xImage
  •  

昨天用 AI 輔助學習了 KMP 最佳實踐,今天該來實戰了。

作為系統設計師,我要從一張白紙開始規劃整個專案架構。

還記得第一次看到 Kotlin Multiplatform 的專案結構時,我整個人是懵的:

your-project/
├── shared/
├── androidApp/
├── iosApp/
└── desktopApp/

這看起來很簡單對吧?但當你真正開始寫程式碼時,問題來了:

  • 業務邏輯放哪裡?
  • UI 程式碼怎麼組織?
  • 平台特定的程式碼如何管理?
  • 怎麼確保程式碼的可測試性?

今天,讓我分享從零開始設計 Grimo 專案架構的完整歷程。

理解 KMP 的核心理念

共享 vs 平台特定

KMP 的核心理念很簡單:Write Once, Run Everywhere(該共享的部分)。

但這不代表所有東西都要共享。關鍵是找到平衡點:

// 適合共享的
class ProjectRepository {
    suspend fun getProjects(): List<Project>  // 業務邏輯
}

// 不適合共享的
@Composable
fun IOSNavigationBar() {  // 平台特定 UI
    // iOS 特有的導航列
}

expect/actual 機制

KMP 提供了優雅的方式處理平台差異:

// commonMain - 定義預期
expect fun getPlatformName(): String
expect class DatabaseDriver

// desktopMain - 實際實作
actual fun getPlatformName(): String = "Desktop"
actual class DatabaseDriver : SqlDriver {
    // SQLite for Desktop
}

// iosMain - 實際實作
actual fun getPlatformName(): String = "iOS"
actual class DatabaseDriver : SqlDriver {
    // SQLite for iOS (不同的路徑處理)
}

引入 Clean Architecture

為什麼選擇 Clean Architecture?

作為一人公司,我需要的架構必須易於維護、易於測試、易於擴展,還要關注點分離。

未來的我要能快速理解。沒有 QA 團隊,必須靠自動化測試。要支援未來的多平台需求。一次只專注一件事。

Clean Architecture 完美符合這些需求。

架構層級設計

┌─────────────────────────────────────┐
│         Presentation Layer          │ <- UI、ViewModel
├─────────────────────────────────────┤
│           Domain Layer              │ <- 業務邏輯、Use Cases
├─────────────────────────────────────┤
│            Data Layer               │ <- Repository、Data Source
├─────────────────────────────────────┤
│         Infrastructure              │ <- Database、Network、檔案系統
└─────────────────────────────────────┘

每一層都有明確的職責:

  • Presentation: 負責 UI 呈現和使用者互動
  • Domain: 純粹的業務邏輯,不依賴任何框架
  • Data: 資料的存取和轉換
  • Infrastructure: 與外部系統的實際互動

Grimo 的專案結構設計

初版架構(錯誤示範)

一開始,我把所有東西都放在 shared:

shared/
├── commonMain/
│   ├── domain/
│   ├── data/
│   ├── presentation/    # 錯誤:UI 不該在這
│   │   ├── screens/
│   │   ├── viewmodels/
│   │   └── theme/
│   └── di/

問題來了。UI 程式碼不該在 shared/commonMain。違反 KMP 最佳實踐。限制了平台特定的 UI 優化。

改進版架構(現行方案)

經過重構後的架構:

grimo/
├── shared/                           # 純業務邏輯模組
│   ├── commonMain/
│   │   ├── kotlin/.../
│   │   │   ├── domain/              # 業務實體、Repository 介面
│   │   │   │   ├── model/
│   │   │   │   │   └── Project.kt   # 專案實體
│   │   │   │   └── repository/
│   │   │   │       └── ProjectRepository.kt  # 介面定義
│   │   │   ├── core/                # 跨平台核心功能
│   │   │   │   ├── error/           # 錯誤處理系統
│   │   │   │   │   ├── AppError.kt
│   │   │   │   │   └── AppResult.kt
│   │   │   │   └── logging/         # 日誌系統
│   │   │   │       └── Logger.kt
│   │   │   ├── data/                # 資料層介面
│   │   │   │   └── database/
│   │   │   │       ├── DatabaseFactory.kt
│   │   │   │       └── MigrationHelper.kt
│   │   │   └── di/                  # 依賴注入
│   │   │       └── CommonModule.kt
│   │   └── sqldelight/              # 資料庫 Schema
│   │       └── migrations/
│   │           ├── 1.sqm
│   │           └── 2.sqm
│   └── desktopMain/                 # Desktop 平台實作
│       └── kotlin/.../
│           ├── core/error/
│           │   └── PlatformErrorMapping.kt
│           └── data/
│               ├── database/
│               │   └── DesktopDatabaseFactory.kt
│               └── repository/
│                   └── ProjectRepositoryImpl.kt
│
├── desktopApp/                      # Desktop 應用程式
│   └── src/jvmMain/
│       └── kotlin/.../
│           ├── Main.kt              # 應用程式進入點
│           ├── presentation/        # UI 層
│           │   ├── App.kt
│           │   ├── theme/           # 主題系統
│           │   │   ├── Color.kt
│           │   │   ├── Typography.kt
│           │   │   └── Theme.kt
│           │   ├── loading/         # 載入功能
│           │   │   ├── LoadingScreen.kt
│           │   │   ├── LoadingState.kt
│           │   │   └── LoadingViewModel.kt
│           │   └── projectlist/     # 專案列表功能
│           │       ├── ProjectListScreen.kt
│           │       ├── ProjectListViewModel.kt
│           │       ├── ProjectListState.kt
│           │       ├── ProjectListIntent.kt
│           │       └── components/
│           └── di/                  # DI 模組
│               └── DesktopAppModule.kt
│
└── docs/                            # 專案文件
    ├── design/                      # 設計文件
    ├── tasks/                       # 任務追蹤
    └── ironman-2025/               # 鐵人賽文章

模組化設計的藝術

模組職責劃分

每個模組都有明確的邊界:

shared 模組

// 該放的
- 業務實體 (Project, Task, User)
- Repository 介面
- Use Cases (如果有複雜業務邏輯)
- 共用工具 (Logger, ErrorHandler)

// 不該放的
- UI 元件
- ViewModel
- 平台特定實作

desktopApp 模組

// 該放的
- Compose UI 元件
- ViewModel (MVI/MVVM)
- 主題和樣式
- 應用程式進入點

// 不該放的
- 業務邏輯
- 資料庫實作

依賴方向

依賴永遠是單向的:

desktopApp → shared
     ↓          ↓
   不知道    不知道
     ↑          ↑
   shared   desktopApp

這確保了:

  • shared 可以獨立測試
  • 業務邏輯不受 UI 影響
  • 容易替換 UI 層

MVI 架構模式的實踐

為什麼選擇 MVI?

相比 MVVM,MVI 提供了什麼?

單向資料流,更容易追蹤狀態變化。不可變狀態,減少並發問題。可預測性,每個動作都有明確的結果。

MVI 實作範例

// State - 不可變的狀態
data class ProjectListState(
    val projects: List<Project> = emptyList(),
    val isLoading: Boolean = false,
    val error: AppError? = null
)

// Intent - 使用者意圖
sealed interface ProjectListIntent {
    object LoadProjects : ProjectListIntent
    data class SelectProject(val id: String) : ProjectListIntent
    object AddProject : ProjectListIntent
}

// ViewModel - 處理意圖,更新狀態
class ProjectListViewModel(
    private val repository: ProjectRepository
) : ViewModel() {
    private val _state = MutableStateFlow(ProjectListState())
    val state = _state.asStateFlow()
    
    fun handleIntent(intent: ProjectListIntent) {
        when (intent) {
            is ProjectListIntent.LoadProjects -> loadProjects()
            is ProjectListIntent.SelectProject -> selectProject(intent.id)
            is ProjectListIntent.AddProject -> navigateToAddProject()
        }
    }
    
    private fun loadProjects() {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true) }
            
            repository.getAllProjects().fold(
                onSuccess = { projects ->
                    _state.update { 
                        it.copy(
                            projects = projects,
                            isLoading = false,
                            error = null
                        )
                    }
                },
                onFailure = { error ->
                    _state.update {
                        it.copy(
                            isLoading = false,
                            error = error
                        )
                    }
                }
            )
        }
    }
}

架構演進:從理論到實踐

v1.0:基礎架構

最初版本只有基本的分層:

專案開始 → 建立 shared/desktopApp
    ↓
定義 Domain 層 (Project entity)
    ↓
實作 Repository 介面
    ↓
加入 SQLDelight

v2.0:加入錯誤處理

發現需要統一的錯誤處理:

// 引入 Result 模式
sealed class AppResult<out T, out E> {
    data class Success<T>(val value: T) : AppResult<T, Nothing>
    data class Failure<E>(val error: E) : AppResult<Nothing, E>
}

// 統一錯誤類型
sealed interface AppError {
    val message: String
    val isRecoverable: Boolean
}

v3.0:UI 層重構

發現 UI 在 shared 的問題,執行重構:

移動所有 UI 相關檔案
    ↓
shared/presentation → desktopApp/presentation
    ↓
更新依賴注入
    ↓
驗證功能正常

架構設計的關鍵決策

決策 1:UI 共享 vs 分離

共享 UI 開發速度快,程式碼重用率高達 95%,但平台優化有限,維護成本低。

分離 UI 開發速度較慢,程式碼重用率約 60-70%,但能完全控制平台優化,維護成本較高。

為什麼選擇分離?

Grimo 需要桌面優先的體驗。未來可能需要深度系統整合。保持架構彈性。

決策 2:資料庫選擇

選擇 SQLDelight 而非 Room。

真正的多平台支援、編譯時 SQL 驗證、類型安全的查詢、優秀的 Migration 支援。

決策 3:依賴注入框架

選擇 Koin 而非 Dagger。

簡單易用、KMP 原生支援、不需要註解處理器、對一人團隊友好。

實戰心得:架構不是一成不變的

架構演進的觸發點

功能需求變化,新功能可能需要新的架構支援。效能瓶頸,發現效能問題時的架構調整。最佳實踐更新,技術生態的演進(如 iOS 穩定版)。團隊成長,從一人到團隊的架構適應。

保持架構彈性的技巧

  1. 介面優於實作
// 定義介面,而非直接依賴實作
interface ProjectRepository {
    suspend fun getProjects(): AppResult<List<Project>, AppError>
}
  1. 模組邊界清晰
// 每個模組有明確的公開 API
// internal 修飾符限制模組內部存取
internal class ProjectRepositoryImpl : ProjectRepository
  1. 漸進式改進
不要試圖一次設計完美架構
    ↓
從簡單開始,逐步演進
    ↓
保持重構的勇氣

架構設計的工具箱

視覺化工具

我使用簡單的文字圖表記錄架構:

Presentation Layer (UI)
        ↓
    ViewModels
        ↓
     Use Cases
        ↓
   Repositories
        ↓
   Data Sources

文件工具

  • 架構決策記錄 (ADR):記錄重要決策
  • README:快速上手指南
  • 設計文件:詳細的設計說明

程式碼分析工具

# 檢查模組依賴
./gradlew :shared:dependencies

# 程式碼複雜度分析
./gradlew detekt

# 架構一致性檢查
./gradlew konsist

架構是活的

經過這段時間的實踐,我最大的體悟是:架構不是雕像,而是生物。

好的架構應該解決實際問題,而非炫技。適應變化,而非僵化。易於理解,而非過度設計。支援成長,而非限制發展。

作為一人公司的系統設計師,我沒有架構評審委員會,沒有技術總監把關。

但這反而讓我更專注於實用性。

每個架構決策都要問自己:這真的解決了問題嗎?三個月後的我能理解嗎?這會讓開發更快還是更慢?

記住,最好的架構是演進出來的,不是設計出來的。

今日金句

「架構不是為了炫技,而是為了讓未來的自己不想打現在的自己。」

關於作者:Sam,一人公司創辦人。正在打造 Grimo,一個智能任務管理和分配平台。

專案連結GitHub - grimostudio


上一篇
Day 08: 架構師學習之路:從 KMP 官方文件到最佳實踐
系列文
30 天一人公司的 AI 開發實戰9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言